Stilometrie mit Python III¶

In [1]:
import pandas as pd
import plotly.graph_objects as go
import statistics as stats
import numpy as np
import os
import re
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.model_selection import train_test_split, cross_val_score
from sklearn.neighbors import KNeighborsClassifier
from sklearn import svm, preprocessing, metrics
from sklearn.linear_model import LogisticRegression
from sklearn.naive_bayes import GaussianNB
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
import matplotlib.pyplot as plt

In diesem Notebook geht es darum, Autoren aufgrund sprachlicher Merkmale ihrer Texte korrekt zu identifizieren.

  • Im ersten Teil sehen wir uns dazu an, wie gut die Maße dazu geeignet sind, die wir beim letzten Mal berechnet haben.
  • Im zweiten Teil schauen wir, ob uns einzelne Wortarthäufigkeiten eine genauere Klassifikation erlauben.
  • Im dritten Teil nehmen wir stattdessen die relativen Häufigkeiten der häufigsten Funktionswörter als Prädiktoren.
  • Und zuletzt ziehen wir noch die relativen Häufigkeiten von Interpunktionszeichen heran.

Erster Teil: abstraktere Maße¶

Das basedir gibt den Pfad zum Verzeichnis an, in dem sich folgendes befinden sollte:

  • meta.csv mit den Metadaten der von AO3 gesammelten Texte
  • measures.csv mit den Maßen, die im letzten Notebook berechnet wurden
  • der Ordner tagged-stanza mit den annotierten Einzeltexten (die Dateien sind nach den IDs der Texte benannt)

All diese Dateien befinden sich auf StudOn im Archiv ao3_authorship.7z.

basedir solltet ihr auf euren eigenen Rechnern natürlich anpassen, damit die Dateien auch eingelesen werden können.

In [2]:
basedir = r'ao3_authorship/'
meta = pd.read_csv(basedir + r'meta.csv')
In [3]:
measures = pd.read_table(basedir + 'measures.tsv', quoting=3) # aus stilometrie2.ipynb
measures = meta.merge(measures, on='id')

Wir werfen wieder die Kollaborationen raus (weil diese Labels sonst jeweils nur einmal vorhanden wären – damit ließe sich nicht viel anfangen):

In [4]:
measures = measures[~measures.author.isin(['Alma_Anor,merripestin', 'Argus_Persa,Bibibabubi,emma_screams,Kidhuzural,Ulan', 'emma_screams,Ulan'])]
measures.head()
Out[4]:
id url author title rating archive_warnings categories fandoms relationships characters ... hits summary notes STTR_0250 STTR_0500 STTR_0750 STTR_1000 MTLD Lexical_density Avg_sentence_length
0 1006420 https://archiveofourown.org/works/1006420?view... Ablissa A Different Sort of Adventure Teen And Up Audiences No Archive Warnings Apply F/M Doctor Who,Doctor Who (2005),Doctor Who & Rela... Tenth Doctor/Rose Tyler,Ninth Doctor/Rose Tyler Tenth Doctor,Rose Tyler,Ninth Doctor ... 6124 <p>"She was a breath of fresh air in his life,... <p>\n (See the end of the chapter for... 0.609671 0.512356 0.458091 0.421514 103.358394 0.297362 12.0
1 1074849 https://archiveofourown.org/works/1074849?view... Ablissa Is She Happy? General Audiences No Archive Warnings Apply F/M Doctor Who,Doctor Who (2005),Doctor Who & Rela... Tenth Doctor/Rose Tyler,Eleventh Doctor/Rose T... Tenth Doctor,Eleventh Doctor,Rose Tyler,Rose T... ... 4470 <p>On his darkest day, the Doctor meets a fami... NaN 0.577778 0.497000 0.432889 0.407500 73.042222 0.305998 8.0
2 5510432 https://archiveofourown.org/works/5510432?view... Ablissa First Impressions (Perhaps I Was Wrong) Mature No Archive Warnings Apply M/M Phandom/The Fantastic Foursome (YouTube RPF) Dan Howell/Phil Lester,Dan Howell & Phil Leste... Dan Howell,Phil Lester,Chris Kendall,PJ Liguor... ... 84173 <p>Phil Lester goes back to university for his... <p>\n (See the end of the chapter for... 0.630932 0.536614 0.484333 0.447561 122.527927 0.338135 14.0
3 5747662 https://archiveofourown.org/works/5747662?view... Ablissa Secrets We Didn't Need To Keep Teen And Up Audiences No Archive Warnings Apply M/M Phandom/The Fantastic Foursome (YouTube RPF) Dan Howell/Phil Lester,Dan Howell & Phil Lester Dan Howell,Phil Lester,Louise Pentland Watson ... 22186 <p>Dan Howell. Twenty-four. In love with his b... <p>Hi! This is just a Phan one-shot I wrote a ... 0.625556 0.532000 0.484444 0.447778 121.752209 0.340645 13.0
4 6366910 https://archiveofourown.org/works/6366910?view... Ablissa It's Just A Formality Teen And Up Audiences No Archive Warnings Apply M/M Phandom/The Fantastic Foursome (YouTube RPF) Dan Howell/Phil Lester,Dan Howell & Phil Lester Dan Howell,Phil Lester ... 10023 <p>"Well, I guess we've never really told you,... NaN 0.633538 0.540333 0.487333 0.454333 119.235779 0.329418 12.0

5 rows × 30 columns

Um mit Scikit-learn etwas klassifizieren können, müssen wir zuerst definieren, was abhängige Variable (oder Zielvariable) und was unabhängige Variablen (oder Prädiktoren) sein sollen. Die abhängige Variable (Name des Autors) nennen wir y, die Menge der unabhängigen Variablen X.

Als unabhängige Variablen nehmen wir alle Spalten von STTR_0250 bis einschl. Avg_sentence_length – ihr könnt hier aber gerne mit verschiedenen Teilmengen ausprobieren, wie sich die Modellqualität abhängig von der Anzahl und der Auswahl der Variablen verändert.

In [5]:
y = measures['author']
X = measures.loc[:, 'STTR_0250':'Avg_sentence_length']

Im nächsten Schritt standardisieren wir die Prädiktoren, sodass sie vergleichbar werden. Dazu setzen wir für jeden Prädiktor den Mittelwert auf 0 und die Standardabweichung auf 1 (wie in einer Standardnormalverteilung). Mathematisch ist das sehr einfach: Von jedem Einzelwert wird das arithmetische Mittel abgezogen, das Ergebnis wird dann durch die Standardabweichung geteilt.

Als Beispiel:

In [6]:
(measures['Avg_sentence_length'] - measures['Avg_sentence_length'].mean()) / measures['Avg_sentence_length'].std()
Out[6]:
0     -0.657505
1     -1.360084
2     -0.306216
3     -0.481861
4     -0.657505
         ...   
187   -0.833150
188    1.625874
189    2.328452
190    1.450229
191    0.747651
Name: Avg_sentence_length, Length: 189, dtype: float64

Die einzelnen Werte, die sich daraus ergeben, nennt man auch z-Werte. Ein z-Wert gibt an, wie viele Standardabweichungen der ursprüngliche Wert vom arithmetischen Mittel der Verteilung entfernt ist.

Scikit-learn stellt hierfür als Abkürzung die Funktion scale() bereit:

In [7]:
X_scaled = preprocessing.scale(X) # alle Prädiktoren standardisieren
X_scaled
Out[7]:
array([[-0.2970513 , -0.64225821, -0.77193066, ..., -0.29457692,
        -2.48689186, -0.65925175],
       [-1.78354114, -1.28902413, -1.78784536, ..., -1.99129003,
        -2.11529544, -1.36369603],
       [ 0.69387807,  0.37942686,  0.28588944, ...,  0.7782893 ,
        -0.73243593, -0.30702961],
       ...,
       [ 0.52479275,  0.81303066,  1.13240322, ..., -0.05786675,
         1.87086521,  2.33463642],
       [-0.85797237, -0.46198618, -0.25349915, ..., -1.00792158,
         1.60269392,  1.45408107],
       [-0.0856054 ,  0.26307469,  0.50187981, ..., -0.38359841,
         1.27928788,  0.7496368 ]])

(In der letzten Zeile sieht man die Werte für Avg_sentence_length. Die kleinen Abweichungen im Vergleich zu unserer eigenen Berechnung ergeben sich daraus, dass Scikit-learn hier – aus welchem Grund auch immer – die unkorrigierte Standardabweichung verwendet. Das sollte letztlich aber irrelevant sein.

In späteren Schritten teilen wir X und y auf. Es ist dann empfehlenswert, X nicht schon vorab zu standardisieren, sondern mit Pipelines und StandardScaler() zu arbeiten.)

Wir können nun direkt ein Modell mit unseren y- und X-Werten trainieren. Wir starten mit einer linearen Support Vector Machine (SVM). Um zu sehen, wie gut das Modell ist, überprüfen wir, wie gut Vorhersagen und tatsächliche Werte übereinstimmen (es gibt aber noch andere Maße für die Modellgüte).

In [8]:
mod = svm.SVC(kernel='linear') # siehe auch: https://scikit-learn.org/stable/modules/svm.html
mod.fit(X_scaled, y)

y_pred = mod.predict(X_scaled)

mod.score(X_scaled, y) # ebenso: metrics.accuracy_score(y, y_pred)
Out[8]:
0.6243386243386243

62% der Autoren korrekt vorhersagt – gar nicht schlecht für den Anfang! Problematisch ist aber, dass wir unser Modell an denselben Daten getestet haben, mit denen wir es auch trainiert haben. Das heißt, es kann gut sein, dass sich unser Modell zu stark an die konkreten Daten angepasst hat (Überanpassung, overfitting) und bei anderen Daten wesentlich schlechtere Vorhersagen liefern würde (sich also nicht gut generalisieren lässt).

Deshalb ist es grundsätzlich sinnvoll, die Daten in Trainings- und Testdaten aufzuteilen. Mit den Trainingsdaten wird das Modell gefüttert, vorausgesagt werden dann die y-Werte der Testdaten.

Scikit-learn stellt dafür netterweise die Funktion train_test_split() bereit, mit der man seine Daten zufällig in Test- und Trainingsdaten aufteilen kann. Dabei kann man auch den Anteil der Testdaten festlegen (test_size) und ob nach bestimmten Variablenausprägungen stratifiziert werden soll (stratify) – in unserem Fall bietet es sich z.B. an, dass die einzelnen Autoren in Test- und Trainingsdaten prozentual immer ungefähr gleich häufig vertreten sind.

In [9]:
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y) # random_state zur Reproduzierbarkeit der Ergebnisse
In [10]:
mod = svm.SVC(kernel='linear')
pipe = make_pipeline(StandardScaler(), mod) # standardisieren, dann Modell anpassen
pipe.fit(X_train, y_train) # Pipeline auf Trainingsdaten anwenden

y_pred = pipe.predict(X_test)

pipe.score(X_test, y_test) # hierbei werden über die Pipeline auch die Testdaten separat standardisiert
Out[10]:
0.5263157894736842

Die Vorhersage von Daten, die das Modell nicht gesehen hat, klappt hier also tatsächlich ein gutes Stück schlechter!

Da der Datensatz zufällig aufgeteilt wird, kann es passieren, dass wir eine besonders günstige oder ungünstige Aufteilung erwischen. Bei einer Wiederholung (mit anderem Wert von random_state oder ohne die Angabe dieses Arguments) käme ein anderer Wert heraus.

Bei der Kreuzvalidierung tun wir genau das: Wir teilen den Datensatz mehrmals neu auf, berechnen für jedes Modell ein Maß der Vorhersagequalität (oder mehrere) und betrachten dann die Ergebnisse. Wenn sie nah beieinander sind, ist unser Modell wahrscheinlich relativ robust. Wenn sie stärker streuen, sollten wir ihm dagegen weniger Vertrauen schenken ...

Es gibt verschiedene Methoden der Kreuzvalidierung. Sehr verbreitet ist k-fache Kreuzvalidierung, bei der der Datensatz in k möglichst gleich große Teile (folds) aufgeteilt wird. Trainingsdaten für ein einzelnes Modell sind k - 1 dieser Teile, der letzte Teil stellt die Testdaten dar. Insgesamt werden damit dann k Modelle erstellt.

In der Praxis hat sich 10-fache Kreuzvalidierung besonders bewährt. Weil unser Datensatz nicht allzu groß ist, führe ich in diesem Notebook aber nur 5-fache durch. (Gute Praxis ist auch, ganz zu Beginn einen Teil der Daten komplett zurückzuhalten, nur den Rest zur Modellbildung und Kreuzvalidierung zu verwenden und das letztlich ausgewählte Modell ganz zum Schluss an den zurückgehaltenen Daten auf die Probe zu stellen. Das sparen wir uns hier.)

In [11]:
cross_val_score(pipe, X, y, cv=5) # hier automatisch stratifiziert
Out[11]:
array([0.47368421, 0.52631579, 0.39473684, 0.44736842, 0.48648649])

Schön an Scikit-learn ist, dass wir verschiedene statistische Methoden auf die gleiche Weise anwenden können. Es folgen einige Beispiele.

Ein naiver Bayes-Klassifikator wird gerne als Baseline (zum Vergleich verschiedener Modelle) verwendet:

In [12]:
gnb = GaussianNB() # https://scikit-learn.org/stable/modules/naive_bayes.html
pipe = make_pipeline(StandardScaler(), gnb)
pipe.fit(X_train, y_train)

y_pred = pipe.predict(X_test)

pipe.score(X_test, y_test)
Out[12]:
0.42105263157894735
In [13]:
cross_val_score(pipe, X, y, cv=5)
Out[13]:
array([0.42105263, 0.44736842, 0.44736842, 0.39473684, 0.37837838])

k-nearest neighbours:

In [14]:
knn = KNeighborsClassifier(n_neighbors=6) # https://scikit-learn.org/stable/modules/generated/sklearn.neighbors.KNeighborsClassifier.html
pipe = make_pipeline(StandardScaler(), knn)
pipe.fit(X_train, y_train)

y_pred = pipe.predict(X_test)

pipe.score(X_test, y_test)
Out[14]:
0.39473684210526316
In [15]:
cross_val_score(pipe, X, y, cv=5)
Out[15]:
array([0.34210526, 0.39473684, 0.31578947, 0.47368421, 0.43243243])

Und logistische Regression (da logistische Regression eigentlich nur binär klassifizieren kann – entweder Klasse A oder Klasse B –, kombiniert Scikit-learn hier einfach mehrere logistische Regressionsmodelle):

In [16]:
lr = LogisticRegression(multi_class='multinomial') # https://scikit-learn.org/stable/modules/generated/sklearn.linear_model.LogisticRegression.html
pipe = make_pipeline(StandardScaler(), lr)
pipe.fit(X_train, y_train)

y_pred = pipe.predict(X_test)

pipe.score(X_test, y_test)
Out[16]:
0.5526315789473685
In [17]:
cross_val_score(pipe, X, y, cv=5)
Out[17]:
array([0.44736842, 0.5       , 0.42105263, 0.47368421, 0.45945946])

Zweiter Teil: Häufigkeiten von Wortarten¶

Basteln wir uns hier zuerst eine Tabelle mit den relativen Häufigkeiten jeder Wortart pro Text. (Vermutlich geht das auch effizienter, aber darum geht's hier gerade nicht.)

In [18]:
colnames = ['id', 'token', 'lemma', 'upos', 'xpos', 'feats', 'head', 'deprel', 'deps', 'misc'] # Spaltennamen für die annotierten Texte
In [19]:
ids = []
upos = []
tokens = []
with os.scandir(basedir + 'tagged-stanza/') as it:
    for entry in it:
        # für jede Datei im Verzeichnis:
        if entry.name.endswith(".tsv") and entry.is_file():
            # ID aus dem Dateinamen extrahieren:
            textid = int(re.search(r'([0-9]+)(.tsv)', entry.name).group(1))
            # Datei als DataFrame einlesen:
            text = pd.read_table(entry.path, names=colnames, quoting=3)
            # Anzahl der Tokens im Text an Liste "tokens" anhängen:
            tokens.append(len(text))
            # ID an Liste "ids" anhängen:
            ids.append(textid)
            # Series der Wortarten als Eintrag an Liste "upos" anhängen:
            upos.append(text['upos'])

Wir könnten uns die Häufigkeiten alle selbst berechnen und daraus eine Tabelle basteln, müssten dabei aber darauf achten, dass manche Wortarten u.U. gar nicht in einem Text vorkommen (v.a. so etwas wie SYM oder X).

Wir sparen uns Arbeit, wenn wir auf die effiziente Funktion CountVectorizer() zurückgreifen, die Scikit-learn bereitstellt. Gedacht ist sie eigentlich für ganze Texte und möchte deshalb erst tokenisieren, aber wir können das mit einem eigenen Pseudotokenisierer (identity_tokenizer()) umgehen:

In [20]:
def identity_tokenizer(text):
    return text

vectorizer = CountVectorizer(tokenizer=identity_tokenizer, lowercase=False)
upos = vectorizer.fit_transform(upos)

upos = pd.DataFrame(upos.toarray(),
                    columns=vectorizer.get_feature_names_out(),
                    index=ids)
upos
Out[20]:
ADJ ADP ADV AUX CCONJ DET INTJ NOUN NUM PART PRON PROPN PUNCT SCONJ SYM VERB X
1006420 6792 9737 11659 8216 4168 8278 1076 13478 696 4071 19634 2750 21507 3007 25 15952 13
1016975 116 117 92 65 46 85 1 250 12 33 78 78 215 16 3 152 0
10290932 389 671 481 389 237 483 20 1080 16 268 1244 250 1201 138 0 1158 0
1042187 201 310 288 177 210 205 10 525 10 154 424 184 528 102 0 550 0
1042258 187 299 227 122 154 223 10 544 5 108 415 129 440 92 0 487 0
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
9616364 385 611 461 356 182 403 48 930 16 260 1048 253 1169 141 0 1039 0
961984 1670 3435 2179 1850 1069 2631 93 5314 108 1167 5374 1768 6038 870 1 5283 0
964610 535 702 643 554 289 509 59 1174 36 285 836 482 1327 149 1 1046 0
9768917 360 675 389 360 195 475 32 1061 14 254 1011 290 1160 166 0 1051 0
984652 71 114 67 80 39 80 8 162 2 45 161 59 231 16 0 163 0

192 rows × 17 columns

Weil die Texte verschieden lang sind, können wir die absoluten Häufigkeiten schlecht direkt vergleichen. Also berechnen wir nun die relativen Häufigkeiten, indem wir die Werte in jeder Reihe (=> axis=0) durch die Zahl der Tokens im entsprechenden Text teilen (diese Zahlen haben wir uns vorhin in der Liste tokens gespeichert).

In [21]:
upos_rel = upos.divide(tokens, axis=0)
upos_rel
Out[21]:
ADJ ADP ADV AUX CCONJ DET INTJ NOUN NUM PART PRON PROPN PUNCT SCONJ SYM VERB X
1006420 0.051824 0.074295 0.088960 0.062689 0.031802 0.063162 0.008210 0.102839 0.005311 0.031062 0.149810 0.020983 0.164102 0.022944 0.000191 0.121716 0.000099
1016975 0.085357 0.086093 0.067697 0.047829 0.033848 0.062546 0.000736 0.183959 0.008830 0.024283 0.057395 0.057395 0.158205 0.011773 0.002208 0.111847 0.000000
10290932 0.048474 0.083614 0.059938 0.048474 0.029533 0.060187 0.002492 0.134579 0.001994 0.033396 0.155016 0.031153 0.149657 0.017196 0.000000 0.144299 0.000000
1042187 0.051831 0.079938 0.074265 0.045642 0.054152 0.052862 0.002579 0.135379 0.002579 0.039711 0.109335 0.047447 0.136153 0.026302 0.000000 0.141826 0.000000
1042258 0.054329 0.086868 0.065950 0.035445 0.044741 0.064788 0.002905 0.158048 0.001453 0.031377 0.120569 0.037478 0.127833 0.026729 0.000000 0.141488 0.000000
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
9616364 0.052725 0.083676 0.063133 0.048754 0.024925 0.055190 0.006574 0.127362 0.002191 0.035607 0.143522 0.034648 0.160093 0.019310 0.000000 0.142290 0.000000
961984 0.042986 0.088417 0.056088 0.047619 0.027516 0.067722 0.002394 0.136782 0.002780 0.030039 0.138327 0.045508 0.155418 0.022394 0.000026 0.135985 0.000000
964610 0.062015 0.081372 0.074533 0.064217 0.033499 0.059001 0.006839 0.136084 0.004173 0.033036 0.096905 0.055871 0.153819 0.017271 0.000116 0.121247 0.000000
9768917 0.048045 0.090084 0.051915 0.048045 0.026024 0.063392 0.004271 0.141599 0.001868 0.033898 0.134926 0.038703 0.154811 0.022154 0.000000 0.140264 0.000000
984652 0.054700 0.087827 0.051618 0.061633 0.030046 0.061633 0.006163 0.124807 0.001541 0.034669 0.124037 0.045455 0.177966 0.012327 0.000000 0.125578 0.000000

192 rows × 17 columns

Jetzt verbinden wir diese Tabelle mit measures:

In [22]:
upos = upos.assign(id=ids)
measures_upos = measures.merge(upos, on='id')

Und wir können uns wieder ein Modell basteln:

In [23]:
y = measures_upos['author']
X = measures_upos.loc[:, "ADJ":"X"]

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y)

mod = svm.SVC(kernel='linear')
pipe = make_pipeline(StandardScaler(), mod)
pipe.fit(X_train, y_train)

y_pred = pipe.predict(X_test)

pipe.score(X_test, y_test)
Out[23]:
0.23684210526315788

Fünffache Kreuzvalidierung:

In [24]:
cross_val_score(pipe, X, y, cv=5)
Out[24]:
array([0.13157895, 0.15789474, 0.26315789, 0.10526316, 0.24324324])

Ein etwas unterwältigendes Ergebnis. Bringt es wenigstens etwas, wenn wir die neuen Spalten in Kombination mit den Maßen von vorhin verwenden?

In [25]:
y = measures_upos['author']
X = measures_upos.loc[:, "STTR_0250":"X"]

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y)

mod = svm.SVC(kernel='linear')
pipe = make_pipeline(StandardScaler(), mod)
pipe.fit(X_train, y_train)

y_pred = pipe.predict(X_test)

pipe.score(X_test, y_test)
Out[25]:
0.5
In [26]:
cross_val_score(pipe, X, y, cv=5)
Out[26]:
array([0.55263158, 0.52631579, 0.44736842, 0.42105263, 0.54054054])

Anscheinend nicht!

Dritter Teil: Häufigkeiten von Funktionswörtern¶

Wir haben bereits darüber gesprochen, dass Funktionswörter für die Stilometrie von besonderem Interesse sind, weil ihr Gebrauch vermutlich über Genres, Textsorten usw. hinweg relativ konstant ist (mit gewissen Einschränkungen – so kommen z.B. subordinierende Konjunktionen wahrscheinlich seltener in der mündlichen Sprache vor als in der schriftlichen, weil es dort auch weniger Nebensätze gibt).

Während Maße wie die obigen vielleicht besser geeignet sind, um Texte auf höheren Ebenen (z.B. Genre, Textsorte, Geschlecht oder Alter des Autors) voneinander zu unterscheiden, sollte es uns möglich sein, einzelne Autoren nach ihrem Gebrauch von Funktionswörtern relativ klar zu unterscheiden. (Alternativen zu Funktionswörtern: häufigste Wörter, N-Gramme oder Buchstaben-N-Gramme.)

In [27]:
ids = []
func = []
tokens = []
with os.scandir(basedir + 'tagged-stanza/') as it:
    for entry in it:
        if entry.name.endswith(".tsv") and entry.is_file():
            textid = int(re.search(r'([0-9]+)(.tsv)', entry.name).group(1))
            text = pd.read_table(entry.path, names=colnames, quoting=3)
            tokens.append(len(text))
            # text = text.query("upos != 'PUNCT'")
            text['token'] = text['token'].str.replace("[‘’]", "'", regex=True) # normalisieren
            text = text.query("upos in ['ADP', 'AUX', 'CCONJ', 'DET', 'PART', 'SCONJ']")
            text = text['token'].str.lower() + '/' + text['upos']
            ids.append(textid)
            func.append(text)
In [28]:
vectorizer = CountVectorizer(tokenizer=identity_tokenizer, lowercase=False)
func = vectorizer.fit_transform(func)

func = pd.DataFrame(func.toarray(),
                    columns=vectorizer.get_feature_names_out(),
                    index=ids)
func
Out[28]:
!–/ADP &/CCONJ '/AUX '/PART 'a/ADP 'b/AUX 'bout/ADP 'bout/AUX 'bout/DET 'bout/SCONJ ... —so/SCONJ —though/ADP —to/ADP —to/AUX —to/PART —was/AUX —we/AUX —”/ADP “'/AUX …/ADP
1006420 0 0 0 2 0 0 0 0 0 0 ... 0 0 0 0 0 0 0 0 0 0
1016975 0 0 0 0 0 0 0 0 0 0 ... 0 0 0 0 0 0 0 0 0 0
10290932 0 0 0 0 0 0 0 0 0 0 ... 0 0 0 0 0 0 0 0 0 0
1042187 0 0 0 0 0 0 0 0 0 0 ... 0 0 0 0 0 0 0 0 0 0
1042258 0 0 0 1 0 0 0 0 0 0 ... 0 0 0 0 0 0 0 0 0 0
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
9616364 0 0 0 0 0 0 0 0 0 0 ... 0 0 0 0 0 0 0 0 0 0
961984 0 0 0 6 0 0 2 0 0 1 ... 0 0 0 0 0 0 0 0 0 0
964610 0 0 0 1 0 0 0 0 0 0 ... 0 0 0 0 0 0 0 0 0 0
9768917 0 0 0 0 0 0 0 0 0 0 ... 0 0 0 0 0 0 0 0 0 0
984652 0 0 0 3 0 0 0 0 0 0 ... 0 0 0 0 0 0 0 0 0 0

192 rows × 725 columns

Wir sehen hier einige Tokenisierungs- und Taggingfehler (z.B. das Zeichen … als ADP). Das ist aber wahrscheinlich nicht weiter schlimm, weil wir uns gleich auf die häufigsten Funktionswörter beschränken werden (und diese fehlerhaften Token-Tag-Kombinationen sind zum Glück normalerweise selten).

Dazu berechnen wir nun wieder die relativen Häufigkeiten. Damit die Zahlen nicht zu viele Nullen nach dem Komma/Punkt haben, multiplizieren wir sie noch mit 1000, sodass wir für jedes Wort die Häufigkeit pro 1000 Wörter haben.

In [29]:
rel = func.divide(tokens, axis=0) * 1000
rel
Out[29]:
!–/ADP &/CCONJ '/AUX '/PART 'a/ADP 'b/AUX 'bout/ADP 'bout/AUX 'bout/DET 'bout/SCONJ ... —so/SCONJ —though/ADP —to/ADP —to/AUX —to/PART —was/AUX —we/AUX —”/ADP “'/AUX …/ADP
1006420 0.0 0.0 0.0 0.015260 0.0 0.0 0.00000 0.0 0.0 0.00000 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
1016975 0.0 0.0 0.0 0.000000 0.0 0.0 0.00000 0.0 0.0 0.00000 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
10290932 0.0 0.0 0.0 0.000000 0.0 0.0 0.00000 0.0 0.0 0.00000 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
1042187 0.0 0.0 0.0 0.000000 0.0 0.0 0.00000 0.0 0.0 0.00000 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
1042258 0.0 0.0 0.0 0.290529 0.0 0.0 0.00000 0.0 0.0 0.00000 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
9616364 0.0 0.0 0.0 0.000000 0.0 0.0 0.00000 0.0 0.0 0.00000 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
961984 0.0 0.0 0.0 0.154440 0.0 0.0 0.05148 0.0 0.0 0.02574 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
964610 0.0 0.0 0.0 0.115915 0.0 0.0 0.00000 0.0 0.0 0.00000 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
9768917 0.0 0.0 0.0 0.000000 0.0 0.0 0.00000 0.0 0.0 0.00000 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
984652 0.0 0.0 0.0 2.311248 0.0 0.0 0.00000 0.0 0.0 0.00000 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0

192 rows × 725 columns

Wir reduzieren diese Tabelle nun auf die 150 Funktionswörter, die insgesamt am häufigsten vorkommen:

In [30]:
# reduced = rel.iloc[:,((rel.max() > 1) & (rel.std() > .3)).to_list()] # Das ist der Versuch, nur Spalten auszuwählen, die sowohl in einzelnen Texten häufiger vorkommen als auch eine Mindestvarianz über die einzelnen Texte hinweg aufweisen. Müsste noch verfeinert werden.
# reduced = rel.iloc[:,((rel.max() > 1)).to_list()]
reduced = rel.reindex(rel.mean().sort_values(ascending=False).index, axis=1) # Spalten sortiert nach arithmetischem Mittel; alternativ (ausprobieren!): Median (median) oder Maximalwert (max)
reduced = reduced.iloc[:, 0:150] # die ersten 150 Spalten; ausprobieren, wie sich mehr oder weniger Spalten auf die nachfolgenden Modelle auswirken!
reduced
Out[30]:
the/DET and/CCONJ a/DET of/ADP to/PART in/ADP 's/PART was/AUX to/ADP is/AUX ... na/PART quite/DET half/DET among/ADP outside/ADP unless/SCONJ get/AUX below/ADP ere/SCONJ either/DET
1006420 28.658848 21.723041 16.488757 14.077629 15.618920 9.728443 1.243715 10.735623 6.172792 4.578091 ... 0.190754 0.244165 0.091562 0.030521 0.038151 0.076302 0.076302 0.030521 0.0 0.015260
1016975 27.961737 25.754231 16.924209 20.603385 9.565857 16.188374 7.358352 5.150846 5.886681 10.301692 ... 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.0 0.000000
10290932 36.137072 21.433022 15.950156 14.205607 15.077882 8.971963 6.978193 0.872274 9.844237 5.607477 ... 0.000000 0.373832 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.0 0.000000
1042187 29.396596 47.189273 11.861784 15.987622 17.792677 11.603920 14.182568 0.257865 4.383703 15.214028 ... 0.000000 0.000000 0.000000 0.515730 0.000000 0.000000 0.000000 0.000000 0.0 0.000000
1042258 37.478210 38.349797 15.979082 15.398024 15.688553 11.911679 11.330622 9.006392 5.229518 0.581058 ... 0.000000 0.000000 0.290529 0.000000 0.000000 0.000000 0.000000 0.000000 0.0 0.000000
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
9616364 31.909066 19.720624 16.023007 13.147083 16.981649 8.490824 8.353876 1.780334 7.395234 6.025746 ... 0.000000 0.000000 0.000000 0.000000 0.136949 0.000000 0.000000 0.000000 0.0 0.136949
961984 41.544402 19.742600 13.796654 18.893179 14.337194 10.090090 8.082368 8.391248 6.795367 2.213642 ... 0.000000 0.025740 0.000000 0.000000 0.000000 0.102960 0.000000 0.051480 0.0 0.025740
964610 34.079054 24.342182 14.605309 17.271357 14.025733 7.650400 11.011939 16.923612 5.216182 1.159152 ... 0.000000 0.115915 0.000000 0.000000 0.000000 0.463661 0.115915 0.231830 0.0 0.000000
9768917 37.635126 18.817563 16.548779 17.216068 16.682237 8.674763 8.674763 1.334579 10.810089 6.405979 ... 0.000000 0.266916 0.000000 0.000000 0.266916 0.133458 0.000000 0.000000 0.0 0.000000
984652 26.194145 21.571649 22.342065 8.474576 13.097072 11.556240 4.622496 16.178737 6.163328 0.770416 ... 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.0 0.000000

192 rows × 150 columns

In [31]:
reduced = reduced.assign(id=ids)
new = measures.merge(reduced, on='id')
In [32]:
y = new['author']
X = new.loc[:, "the/DET":"either/DET"]
In [33]:
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y) # random_state zur Reproduzierbarkeit der Ergebnisse
In [34]:
mod = svm.SVC(kernel='linear')
pipe = make_pipeline(StandardScaler(), mod)
pipe.fit(X_train, y_train)

y_pred = pipe.predict(X_test)

pipe.score(X_test, y_test)
Out[34]:
0.9210526315789473

Fünffache Kreuzvalidierung:

In [35]:
cross_val_score(pipe, X, y, cv=5)
Out[35]:
array([0.84210526, 0.89473684, 0.97368421, 0.86842105, 0.89189189])

Das sieht doch schon viel besser aus!

Gucken wir uns mal in einer Konfusionsmatrix/Wahrheitsmatrix an, wie das bei einzelnen Autoren aussieht:

In [36]:
metrics.confusion_matrix(y_test, y_pred)
Out[36]:
array([[4, 0, 0, 0, 0, 0, 0, 0, 0, 0],
       [0, 4, 0, 0, 0, 0, 0, 0, 0, 0],
       [0, 0, 2, 0, 0, 0, 0, 0, 0, 0],
       [0, 0, 0, 4, 0, 0, 0, 0, 0, 0],
       [0, 0, 0, 0, 2, 1, 0, 0, 1, 0],
       [0, 0, 0, 0, 0, 4, 0, 0, 0, 0],
       [0, 0, 0, 0, 0, 0, 4, 0, 0, 0],
       [0, 0, 0, 0, 0, 0, 0, 4, 0, 0],
       [0, 0, 0, 0, 1, 0, 0, 0, 3, 0],
       [0, 0, 0, 0, 0, 0, 0, 0, 0, 4]], dtype=int64)

Nicht sehr aussagekräftig? Dann machen wir doch eine Graphik daraus und fügen die Labels hinzu:

In [37]:
metrics.ConfusionMatrixDisplay.from_estimator(pipe, X_test, y_test, xticks_rotation='vertical')
plt.show()

Wir können uns das natürlich auch in Plotly bauen. Dafür brauchen wir zuerst die Labels als Liste.

In [38]:
y_names = pipe.classes_.tolist()

import plotly.figure_factory as ff
cm = metrics.confusion_matrix(y_test, y_pred)
fig = ff.create_annotated_heatmap(cm, x=y_names, y=y_names, annotation_text=cm, colorscale='Viridis')
fig['data'][0]['showscale'] = True
fig.update_layout(
    autosize=False,
    xaxis_title="Predicted label",
    yaxis_title="True label",
)
fig.show()

Vierter Teil: Häufigkeiten von Interpunktionszeichen¶

Hier schauen wir uns noch an, ob der Gebrauch von Interpunktionszeichen Rückschlüsse auf den Autor eines Textes zulässt.

Da verschiedene typographische Zeichen z.T. dieselbe Funktion erfüllen können (z.B. “, ” und "), müssen wir uns überlegen, ob wir diese Zeichen normalisieren, also zusammenfassen wollen. Wenn wir das nicht tun, wird unser Modell möglicherweise sogar etwas besser, weil Autoren sich möglicherweise auch darin unterschieden, inwieweit sie bestimmten typographischen Konventionen folgen. Allerdings kann das mitunter auch von Text zu Text variieren, je nachdem, mit welchem Programm er zuerst verfasst wurde. Bei gedruckten Texten hängt es womöglich auch vom Verlag ab, welche Anführungszeichen bspw. verwendet werden – das wäre also kein Autorsignal und u.U. irreführend.

In [39]:
punct = []
with os.scandir(basedir + 'tagged-stanza/') as it:
    for entry in it:
        if entry.name.endswith(".tsv") and entry.is_file():
            text = pd.read_table(entry.path, names=colnames, quoting=3)
            text = text.query("upos == 'PUNCT'")
            text = text['token']
            # Normalisierung:
            text = text.str.replace("[‘’]", "'", regex=True)
            text = text.str.replace('[“”]', '"', regex=True)
            text = text.str.replace('\.\.\.+', '…', regex=True)
            text = text.str.replace('\?\?+', '??', regex=True)
            text = text.str.replace('!!+', '!!', regex=True)
            text = text.str.replace('\*\*+', '**', regex=True)
            text = text.str.replace('---+', '---', regex=True)
            text = text.str.replace('—', '–', regex=False)
            punct.append(text)
In [40]:
vectorizer = CountVectorizer(tokenizer=identity_tokenizer, lowercase=False)
punct = vectorizer.fit_transform(punct)

punct = pd.DataFrame(punct.toarray(),
                     columns=vectorizer.get_feature_names_out(),
                     index=ids)
punct
Out[40]:
! !! !!1 !!11 !!~ !" !' !-- !? !?!? ... …>>>>>>>> …? …?" …my ……………………………… ♡ 🌟 😘 😞 😤
1006420 596 0 0 0 0 0 0 0 0 0 ... 0 5 0 0 0 0 0 0 0 0
1016975 2 0 0 0 0 0 0 0 0 0 ... 0 0 0 0 0 0 0 0 0 0
10290932 11 0 0 0 0 1 0 0 0 0 ... 0 0 0 0 0 0 0 0 0 0
1042187 4 0 0 0 0 0 0 0 0 0 ... 0 0 0 0 0 0 0 0 0 0
1042258 0 0 0 0 0 0 0 0 0 0 ... 0 0 0 0 0 0 0 0 0 0
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
9616364 8 0 0 0 0 0 0 0 0 0 ... 0 0 0 0 0 0 0 0 0 0
961984 32 0 0 0 0 0 0 0 0 0 ... 0 0 0 0 0 0 0 0 0 0
964610 1 0 0 0 0 0 0 0 0 0 ... 0 0 0 0 0 0 0 0 0 0
9768917 4 0 0 0 0 0 0 0 0 0 ... 0 0 0 0 0 0 0 0 0 0
984652 1 0 0 0 0 0 0 0 0 0 ... 0 0 0 0 0 0 0 0 0 0

192 rows × 313 columns

Wir sehen wieder, dass hier ein bisschen Blödsinn dabei ist. Berechnen wir also wieder relative Häufigkeiten und verkleinern die Tabelle dann.

In [41]:
punct_rel = punct.divide(tokens, axis=0) * 1000
punct_rel
Out[41]:
! !! !!1 !!11 !!~ !" !' !-- !? !?!? ... …>>>>>>>> …? …?" …my ……………………………… ♡ 🌟 😘 😞 😤
1006420 4.547570 0.0 0.0 0.0 0.0 0.000000 0.0 0.0 0.0 0.0 ... 0.0 0.038151 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
1016975 1.471670 0.0 0.0 0.0 0.0 0.000000 0.0 0.0 0.0 0.0 ... 0.0 0.000000 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
10290932 1.370717 0.0 0.0 0.0 0.0 0.124611 0.0 0.0 0.0 0.0 ... 0.0 0.000000 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
1042187 1.031460 0.0 0.0 0.0 0.0 0.000000 0.0 0.0 0.0 0.0 ... 0.0 0.000000 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
1042258 0.000000 0.0 0.0 0.0 0.0 0.000000 0.0 0.0 0.0 0.0 ... 0.0 0.000000 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
9616364 1.095590 0.0 0.0 0.0 0.0 0.000000 0.0 0.0 0.0 0.0 ... 0.0 0.000000 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
961984 0.823681 0.0 0.0 0.0 0.0 0.000000 0.0 0.0 0.0 0.0 ... 0.0 0.000000 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
964610 0.115915 0.0 0.0 0.0 0.0 0.000000 0.0 0.0 0.0 0.0 ... 0.0 0.000000 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
9768917 0.533832 0.0 0.0 0.0 0.0 0.000000 0.0 0.0 0.0 0.0 ... 0.0 0.000000 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
984652 0.770416 0.0 0.0 0.0 0.0 0.000000 0.0 0.0 0.0 0.0 ... 0.0 0.000000 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0

192 rows × 313 columns

In [42]:
punct_reduced = punct_rel.reindex(punct_rel.mean().sort_values(ascending=False).index, axis=1)
punct_reduced = punct_reduced.iloc[:, 0:29]
punct_reduced = punct_reduced.assign(id=ids)
punct_reduced
Out[42]:
, . " ? - ! ; … : -- ... / -" --" !! …. < …? ?! ?? id
1006420 74.325304 43.041684 19.563708 8.522879 4.082131 4.547570 1.968579 4.982489 0.335727 0.000000 ... 0.045781 0.015260 0.000000 0.0 0.0 0.0 0.038151 0.099192 0.0 1006420
1016975 64.753495 57.395143 0.000000 0.735835 7.358352 1.471670 1.471670 0.000000 10.301692 7.358352 ... 0.000000 0.000000 0.000000 0.0 0.0 0.0 0.000000 0.000000 0.0 1016975
10290932 62.928349 51.588785 24.299065 5.109034 1.869159 1.370717 0.000000 0.000000 0.000000 0.000000 ... 0.000000 0.000000 0.000000 0.0 0.0 0.0 0.000000 0.000000 0.0 10290932
1042187 68.849923 28.880866 21.144920 0.773595 2.578649 1.031460 4.899433 1.547189 1.547189 3.867973 ... 0.000000 0.000000 0.000000 0.0 0.0 0.0 0.000000 0.000000 0.0 1042187
1042258 63.916328 25.276002 16.850668 1.452644 4.938989 0.000000 5.520046 1.452644 2.324230 4.938989 ... 0.000000 0.000000 0.000000 0.0 0.0 0.0 0.000000 0.000000 0.0 1042258
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
9616364 65.187620 43.960559 39.852095 4.382361 2.191180 1.095590 0.410846 0.000000 0.000000 0.000000 ... 0.000000 0.273898 0.000000 0.0 0.0 0.0 0.000000 0.000000 0.0 9616364
961984 59.845560 46.151866 33.719434 3.912484 2.110682 0.823681 2.059202 0.643501 1.209781 0.000000 ... 0.000000 0.051480 0.000000 0.0 0.0 0.0 0.000000 0.000000 0.0 961984
964610 60.275878 55.059696 22.371624 5.563927 1.390982 0.115915 1.159152 0.811406 1.043236 4.868436 ... 0.000000 0.000000 0.115915 0.0 0.0 0.0 0.000000 0.000000 0.0 964610
9768917 65.260910 47.244094 33.497931 2.802616 2.402242 0.533832 0.133458 0.266916 0.000000 0.000000 ... 0.000000 0.533832 0.000000 0.0 0.0 0.0 0.000000 0.000000 0.0 9768917
984652 63.944530 64.714946 41.602465 0.770416 4.622496 0.770416 0.770416 0.000000 0.770416 0.000000 ... 0.000000 0.000000 0.000000 0.0 0.0 0.0 0.000000 0.000000 0.0 984652

192 rows × 30 columns

In [43]:
new2 = measures.merge(punct_reduced, on='id')
y = new2['author']
X = new2.loc[:, ",":"??"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y) # random_state zur Reproduzierbarkeit der Ergebnisse
In [44]:
mod = svm.SVC(kernel='linear')
pipe = make_pipeline(StandardScaler(), mod)
pipe.fit(X_train, y_train)

y_pred = pipe.predict(X_test)

pipe.score(X_test, y_test)
Out[44]:
0.7368421052631579

Fünffache Kreuzvalidierung:

In [45]:
cross_val_score(pipe, X, y, cv=5)
Out[45]:
array([0.55263158, 0.81578947, 0.73684211, 0.73684211, 0.78378378])

Gar nicht so schlecht für ein paar Satzzeichen!

Gucken wir mal, was die Kombination mit den sonstigen Merkmalen bringt:

In [46]:
reduced_combined = reduced.merge(punct_reduced, on='id')
reduced_combined
Out[46]:
the/DET and/CCONJ a/DET of/ADP to/PART in/ADP 's/PART was/AUX to/ADP is/AUX ... [ / -" --" !! …. < …? ?! ??
0 28.658848 21.723041 16.488757 14.077629 15.618920 9.728443 1.243715 10.735623 6.172792 4.578091 ... 0.015260 0.045781 0.015260 0.000000 0.0 0.0 0.0 0.038151 0.099192 0.0
1 27.961737 25.754231 16.924209 20.603385 9.565857 16.188374 7.358352 5.150846 5.886681 10.301692 ... 2.207506 0.000000 0.000000 0.000000 0.0 0.0 0.0 0.000000 0.000000 0.0
2 36.137072 21.433022 15.950156 14.205607 15.077882 8.971963 6.978193 0.872274 9.844237 5.607477 ... 0.000000 0.000000 0.000000 0.000000 0.0 0.0 0.0 0.000000 0.000000 0.0
3 29.396596 47.189273 11.861784 15.987622 17.792677 11.603920 14.182568 0.257865 4.383703 15.214028 ... 0.000000 0.000000 0.000000 0.000000 0.0 0.0 0.0 0.000000 0.000000 0.0
4 37.478210 38.349797 15.979082 15.398024 15.688553 11.911679 11.330622 9.006392 5.229518 0.581058 ... 0.000000 0.000000 0.000000 0.000000 0.0 0.0 0.0 0.000000 0.000000 0.0
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
187 31.909066 19.720624 16.023007 13.147083 16.981649 8.490824 8.353876 1.780334 7.395234 6.025746 ... 0.000000 0.000000 0.273898 0.000000 0.0 0.0 0.0 0.000000 0.000000 0.0
188 41.544402 19.742600 13.796654 18.893179 14.337194 10.090090 8.082368 8.391248 6.795367 2.213642 ... 0.000000 0.000000 0.051480 0.000000 0.0 0.0 0.0 0.000000 0.000000 0.0
189 34.079054 24.342182 14.605309 17.271357 14.025733 7.650400 11.011939 16.923612 5.216182 1.159152 ... 0.000000 0.000000 0.000000 0.115915 0.0 0.0 0.0 0.000000 0.000000 0.0
190 37.635126 18.817563 16.548779 17.216068 16.682237 8.674763 8.674763 1.334579 10.810089 6.405979 ... 0.000000 0.000000 0.533832 0.000000 0.0 0.0 0.0 0.000000 0.000000 0.0
191 26.194145 21.571649 22.342065 8.474576 13.097072 11.556240 4.622496 16.178737 6.163328 0.770416 ... 0.000000 0.000000 0.000000 0.000000 0.0 0.0 0.0 0.000000 0.000000 0.0

192 rows × 180 columns

In [47]:
new3 = measures.merge(reduced_combined, on='id')
y = new3['author']
X = new3.loc[:, "the/DET":"??"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y) # random_state zur Reproduzierbarkeit der Ergebnisse
In [48]:
mod = svm.SVC(kernel='linear')
pipe = make_pipeline(StandardScaler(), mod)
pipe.fit(X_train, y_train)

y_pred = pipe.predict(X_test)

pipe.score(X_test, y_test)
Out[48]:
0.9210526315789473

Fünffache Kreuzvalidierung:

In [49]:
cross_val_score(pipe, X, y, cv=5)
Out[49]:
array([0.84210526, 0.92105263, 0.94736842, 0.89473684, 0.94594595])

Das sieht tatsächlich noch ein bisschen besser aus, die Satzzeichen scheinen also nützlich zu sein. Mit logistischer Regression kommen wir zu einem ähnlichen Ergebnis:

In [50]:
lr = LogisticRegression(multi_class='multinomial') # https://scikit-learn.org/stable/modules/generated/sklearn.linear_model.LogisticRegression.html
pipe = make_pipeline(StandardScaler(), lr)
pipe.fit(X_train, y_train)

y_pred = pipe.predict(X_test)

pipe.score(X_test, y_test)
Out[50]:
0.9473684210526315
In [51]:
cross_val_score(pipe, X, y, cv=5)
Out[51]:
array([0.86842105, 0.92105263, 0.94736842, 0.86842105, 0.94594595])
In [52]:
y_names = pipe.classes_.tolist()

import plotly.figure_factory as ff
cm = metrics.confusion_matrix(y_test, y_pred)
fig = ff.create_annotated_heatmap(cm, x=y_names, y=y_names, annotation_text=cm, colorscale='Viridis')
fig['data'][0]['showscale'] = True
fig.update_layout(
    autosize=False,
    xaxis_title="Predicted label",
    yaxis_title="True label",
)
fig.show()

Anhang: Maßzahlen zur Modellbegutachtung¶

Neben der Modellgenauigkeit, die wir uns bisher angesehen haben (accuracy) gibt es noch andere Maßzahlen für Klassifikatoren (siehe auch den deutschen und englischen Wikipediaartikel dazu):

  • Die Präzision (precision) lässt sich für jede Klasse (= jedes Label, jeden Autor) berechnen. Dafür sieht man sich an, wie oft die Klasse insgesamt vorausgesagt wurde, und gibt den Anteil der richtigen Voraussagen daran an. So wurde als Autor z.B. fünfmal daughterofdurinanddestiel vorausgesagt, allerdings sind nur vier dieser Voraussagen richtig. Die Präzision für diesen Autor liegt also bei 0,8. Bei vielen Klassen wie in unserem Fall kann man die Makropräzision als arithmetisches Mittel aller Präzisionswerte berechnen (und bei ungleich verteilten Klassenhäufigkeiten ggf. noch gewichten).
  • Die Sensitivität, Richtig-positiv-Rate oder der Recall (sensitivity, true positive rate, recall) lässt sich ebenfalls für jede Klasse berechnen und gibt an, wie groß der Anteil der richtigen Klassenvoraussagen an allen Vorkommnissen der Klasse ist. Dwimordene kommt in y_test viermal vor, allerdings wurden davon nur drei Texte korrekt klassifiziert (ein Text wurde fälschlicherweise Eastmava zugeordnet). Auch hier lässt sich wieder ein Makrowert berechnen.
  • Das F-Maß (F1 score, auch Sørensen–Dice coefficient) kombiniert Precision und Recall mittels des harmonischen Mittels: $F_1 = 2 \cdot \frac{precision \cdot recall}{precision + recall}$. Auch dies wird für jede Klasse berechnet, sodass man auch einen Makrowert berechnen kann.

Gewichtete Makropräzision:

In [53]:
metrics.precision_score(y_test, y_pred, average='weighted')
Out[53]:
0.9614035087719298

Gewichteter Recall:

In [54]:
metrics.recall_score(y_test, y_pred, average='weighted')
Out[54]:
0.9473684210526315

Gewichtetes F1-Maß:

In [55]:
metrics.f1_score(y_test, y_pred, average='weighted')
Out[55]:
0.9477025898078529

Auf welches Maß man am stärksten achten sollte, hängt von der konkreten Klassifikationsaufgabe ab.

Bei einem Corona-Test ist es z.B. wahrscheinlich nicht allzu dramatisch, wenn ein positives Testergebnis fälschlich zustandekommt (die eigentlich gesunde Person wird dann eben in Quarantäne geschickt, wird nachuntersucht usw.). Das heißt, man möchte hier vielleicht nicht um jeden Preis die Präzision optimieren. Dagegen ist die Sensitivität (der Recall) sehr wichtig: Wenn jemand krank ist, soll der Test das auch feststellen.

Bei einem Spamfilter ist hingegen die Präzision etwas wichtiger. Wenn etwas als Spam erkannt wird, dann soll es auch wirklich Spam sein (denn sonst landet womöglich eine wichtige Mail ungelesen im Spam-Ordner).